feat(bookmarks): add message bookmarks (MSC4438)#601
Conversation
396ee68 to
2a9fa52
Compare
There was a problem hiding this comment.
Pull request overview
Adds MSC4438 “message bookmarks” to the client by persisting a per-user bookmark index + items in Matrix account data, wiring startup sync + UI entry points, and gating rollout behind both an operator experiment flag and a per-user experimental toggle.
Changes:
- Introduces bookmark domain/repository layers (account-data types, CRUD, validation, orphan/tombstone handling) plus Jotai state + init hook to keep local cache in sync.
- Adds Bookmarks UI (sidebar entries, routes, list/filter/group viewer, remove/restore flows) and message context-menu integration.
- Adds experiment framework support in client config, settings toggle UI, and unit tests for domain/repository/state.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/matrix/accountData.ts | Adds MSC4438 account-data event types/prefixes. |
| src/app/state/settings.ts | Adds per-user experimental toggle (enableMessageBookmarks). |
| src/app/state/bookmarks.ts | Adds bookmark Jotai atoms + derived ID Set. |
| src/app/state/bookmarks.test.ts | Tests derived bookmark ID Set atom behavior. |
| src/app/pages/Router.tsx | Adds Bookmarks route under Home and Inbox. |
| src/app/pages/pathUtils.ts | Adds helpers for Home/Inbox bookmarks paths. |
| src/app/pages/paths.ts | Adds bookmarks/ path segment + concrete paths. |
| src/app/pages/client/inbox/Inbox.tsx | Adds Inbox sidebar nav item gated by experiment/setting. |
| src/app/pages/client/home/Home.tsx | Adds Home sidebar nav item gated by experiment/setting. |
| src/app/pages/client/ClientNonUIFeatures.tsx | Mounts bookmark init hook; adjusts in-app audio gating. |
| src/app/pages/client/bookmarks/index.ts | Re-exports Bookmarks page module. |
| src/app/pages/client/bookmarks/BookmarksList.tsx | Implements Bookmarks list UI (filter/group/jump/remove/restore). |
| src/app/hooks/useClientConfig.ts | Adds experiments config typing + deterministic assignment helpers/hook. |
| src/app/hooks/router/useInbox.ts | Adds selection matcher for Inbox Bookmarks route. |
| src/app/hooks/router/useHomeSelected.ts | Adds selection matcher for Home Bookmarks route. |
| src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx | Adds experimental settings tile for bookmarks toggle. |
| src/app/features/settings/experimental/Experimental.tsx | Wires bookmarks toggle into Experimental settings section. |
| src/app/features/room/message/Message.tsx | Adds message context-menu item to add/remove bookmark. |
| src/app/features/bookmarks/useInitBookmarks.ts | Initializes and keeps bookmark atoms synced with account data. |
| src/app/features/bookmarks/useBookmarks.ts | Adds read + action hooks for bookmarks. |
| src/app/features/bookmarks/bookmarkRepository.ts | Implements MSC4438 account-data CRUD + listing/orphan recovery. |
| src/app/features/bookmarks/bookmarkRepository.test.ts | Adds repository unit tests. |
| src/app/features/bookmarks/bookmarkDomain.ts | Defines bookmark types, ID algorithm, validators, item builder. |
| src/app/features/bookmarks/bookmarkDomain.test.ts | Adds domain unit tests + reference vectors. |
| config.json | Adds operator-controlled experiments.messageBookmarks flag. |
| .changeset/message-bookmarks.md | Adds release note entry for message bookmarks feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {groupedByRoom.map((group, i) => ( | ||
| <> | ||
| {i > 0 && <Line variant="Background" size="300" />} | ||
| <BookmarkResultGroup | ||
| key={group.roomId} | ||
| roomId={group.roomId} | ||
| roomName={group.roomName} | ||
| items={group.items} | ||
| highlight={filterTerm} | ||
| onJump={handleJump} | ||
| onRemove={setRemovingItem} | ||
| hour24Clock={hour24Clock} | ||
| dateFormatString={dateFormatString} | ||
| /> | ||
| </> | ||
| ))} |
There was a problem hiding this comment.
groupedByRoom.map(...) returns an un-keyed fragment (<>...</>) containing multiple siblings. React will warn that each child in a list needs a unique key because the fragment itself is the list element; the key on BookmarkResultGroup doesn’t satisfy that. Wrap the block in <Fragment key={...}> (or move the key onto the fragment) so the list element is keyed.
| const mx = useMatrixClient(); | ||
| const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); | ||
| const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); | ||
| const eventId = mEvent.getId() ?? ''; | ||
| const isBookmarked = useIsBookmarked(room.roomId, eventId); | ||
| const { add, remove } = useBookmarkActions(); | ||
|
|
||
| if (!bookmarksExperiment.inExperiment && !enableMessageBookmarks) return null; | ||
|
|
||
| const handleClick = async () => { | ||
| if (isBookmarked) { | ||
| await remove(computeBookmarkId(room.roomId, eventId)); | ||
| } else { | ||
| const item = createBookmarkItem(room, mEvent); | ||
| if (item) await add(item); | ||
| } | ||
| onClose?.(); | ||
| }; |
There was a problem hiding this comment.
MessageBookmarkItem doesn’t guard against mEvent.getId() being missing. With eventId defaulting to '', useIsBookmarked()/computeBookmarkId() can compute a bogus ID (and in the worst case remove the wrong bookmark due to 32-bit hash collisions), and the “Bookmark Message” action can become a silent no-op because createBookmarkItem() returns undefined while the menu still closes. Consider returning null (or disabling the item) when !mEvent.getId().
| * 1. Remove the ID from the index. | ||
| * 2. Soft-delete the item (set deleted: true). | ||
| * | ||
| * Account data events cannot be deleted from the server, so soft-deletion is | ||
| * used. Other clients that encounter the item event can see it is explicitly | ||
| * removed. |
There was a problem hiding this comment.
The doc comment for removeBookmark says the MSC4438 removal order is “remove from index” then “soft-delete the item”, but the implementation intentionally tombstones the item first and updates the index second (to mirror addBookmark’s item-first ordering and avoid transient orphan recovery). Please update the comment to match the actual ordering so future changes don’t “fix” this back accidentally.
| * 1. Remove the ID from the index. | |
| * 2. Soft-delete the item (set deleted: true). | |
| * | |
| * Account data events cannot be deleted from the server, so soft-deletion is | |
| * used. Other clients that encounter the item event can see it is explicitly | |
| * removed. | |
| * 1. Soft-delete the item first (set deleted: true). | |
| * 2. Remove the ID from the index. | |
| * 3. Increment revision and update timestamp. | |
| * 4. Write the updated index. | |
| * | |
| * Account data events cannot be deleted from the server, so soft-deletion is | |
| * used. This implementation intentionally tombstones the item before updating | |
| * the index to mirror addBookmark()'s item-first ordering and avoid transient | |
| * orphan recovery/resurrection if a removal only partially completes. |
|
the amount of commits and line changes this pr wants to merge seems a bit off for the problem stated 🤔 |
d82f8e4 to
879cc78
Compare
36231cc to
3074325
Compare
3074325 to
833ae7b
Compare
… not in local timeline
The delete icon on archived bookmarks was calling remove() which only moves active → deleted and cannot find entries already in the deleted list, so it was a no-op locally. Fix: add a purge() action that removes the entry from bookmarkDeletedListAtom immediately (optimistic update) and writes purged:true to the server-side account data item event. Since Matrix account data cannot be deleted, the purged flag is used by listDeletedBookmarks() to skip the item on next load, so it stays gone across all devices after the next sync.
…nder picker - Add enableBookmarkReminders setting (default false, requires enableMessageBookmarks to be shown in settings) - Show a 'Bookmark Reminders' sub-toggle in the MSC4438 experimental settings tile, visible only when bookmarks are enabled - Gate RemindersFeature on both enableMessageBookmarks and enableBookmarkReminders so the SW only receives reminder updates when the feature is active - New reminderRepository: setBookmarkReminder / clearBookmarkReminder / listReminders — read/write the moe.sable.bookmarks.reminders account data event - New useBookmarkReminders hook: reads reminders from account data and stays live via useAccountDataCallback - New useBookmarkReminderActions hook: set and clear reminder callbacks - Add Bell/BellRing icon button to each active bookmark in BookmarksList (visible when reminders setting is on); clicking opens an inline datetime-local picker with Set/Clear actions
Description
Implements MSC4438 message bookmarks — lets users save any room event to a personal bookmark list stored in Matrix account data.
Commits in this PR:
feat(bookmarks): add message bookmarks (MSC4438)— full feature implementation:BookmarkItemContent/BookmarkIndextypes,bookmarkRepository.tsCRUD operations (addBookmark,removeBookmark,listBookmarks,useInitBookmarks), auseBookmarksReact hook, context menu integration, and the bookmarks viewer panel.test(bookmarks): add unit tests for MSC4438 bookmark domain and repository— covers the core repository logic and hook behaviour.fix(bookmarks): add missing focusId to MSC4438 settings SettingTile— prevents a React key-prop warning that appeared in the Settings modal.fix(bookmarks): soft-delete item before updating index in removeBookmark— corrects the write order inremoveBookmarkto mirror the item-first ordering ofaddBookmark, preventing orphan-recovery inlistBookmarksfrom transiently resurrecring a bookmark between the two writes.fix(bookmarks): wire useInitBookmarks and fix orphan tombstoning— mountsBookmarksFeatureinClientNonUIFeaturessouseInitBookmarks()is actually called on startup (the bookmark list was never populated without this). Also replacesreadItem()(which validates and may return null for malformed items) with a direct account-data read in the tombstone path, so malformed or partially-written bookmark items can always be fully deleted. Includes regression tests for both cases.Fixes #
Type of change
Checklist:
AI disclosure:
The
bookmarkRepository.tsCRUD helpers and theuseInitBookmarkshook skeleton were drafted with AI assistance and reviewed against the MSC4438 spec and Sable's existing account-data patterns. The tombstone fix logic and theClientNonUIFeatureswiring were written manually after tracing the runtime call graph.